package es.udima.geo.geoserver.business;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Calendar;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

import es.udima.geo.geoserver.entities.User;
import es.udima.geo.geoserver.entities.UserAction;
import es.udima.geo.geoserver.entities.UserActivityLog;
import es.udima.geo.geoserver.entities.UserSession;
import es.udima.geo.geoserver.entities.UserSessionError;
import es.udima.geo.geoserver.entities.UserUpdateResult;
import es.udima.geo.geoserver.entities.UserUpdateResultError;
import es.udima.geo.geoserver.persistence.UserActivityLogDAO;
import es.udima.geo.geoserver.persistence.UserDAO;

/**
 * <p>
 * Clase controladora de los usuarios.
 * </p>
 * <p>
 * Es la encargada de realizar las funciones de negocio relativas a los
 * usuarios.
 * </p>
 * 
 * @author david
 * 
 */
public class UserController {

    /**
     * Tiempo máximo de vigencia de una sesión de usuario sin que está sea
     * utilizada.
     */
    private static final long SESSION_EXPIRING_TIME = 10 * 60 * 1000;

    /**
     * Longitud mínima de la clave de usuario.
     */
    private static final int MINPASSWORDLENGTH = 8;

    /**
     * Longitud mínima del nombre de usuario.
     */
    private static final int MINUSERNAMELENGTH = 8;

    /**
     * Tiempo mínimo del intervalo de captura de datos gps (en milisegundos).
     */
    private static final long MINGPSFETCHTIME = 1000;

    /**
     * Tiempo máximo del intervalos de captura de datos gps (en milisegundos).
     */
    private static final long MAXGPSFETCHTIME = 5000;

    /**
     * Lista de las sesiones de usuario activas.
     */
    private static Map<String, UserSession> userSessions = new HashMap<String, UserSession>();

    /**
     * Constructor
     */
    public UserController() {
    }

    /**
     * <p>
     * Recupera de base de datos el usuario con el nombre de usuario el pasado
     * como parámetro.
     * </p>
     * 
     * @param userName
     *            Nombre de usuario por el que se trata de recuperar un usuario.
     * @return Usuario leído o null si no existía un usuario con el nombre de
     *         usuario pasado como parámetro.
     */
    public User retrieveUserByUserName(String userName) {
	UserDAO dao = new UserDAO();
	User user = dao.retrieveUserByUserName(userName);
	completeUserSessionData(user);
	return user;
    }

    /**
     * <p>
     * Recupera de base de datos el usuario con el id de usuario el pasado como
     * parámetro.
     * </p>
     * 
     * @param userId
     *            Identificador de usuario por el que se trata de recuperar un
     *            usuario.
     * @return Usuario leído o null si no existía un usuario con el id de
     *         usuario pasado como parámetro.
     */
    public User retrieveUserByUserId(String userId) {
	UserDAO dao = new UserDAO();
	User user = dao.retrieveUserByUserId(userId);
	completeUserSessionData(user);
	return user;
    }

    /**
     * <p>
     * Recupera de base de datos los usuarios activos.
     * </p>
     * <p>
     * Se entiende como usuarios activos aquellos que no han sido marcados en la
     * tabla de usuarios como inactivos.
     * </p>
     * 
     * @return Lista de usuarios activos.
     */
    public List<User> retrieveAllUsers() {
	UserDAO dao = new UserDAO();
	List<User> res = dao.retrieveActiveUsers();
	for (User user : res) {
	    completeUserSessionData(user);
	}
	return res;
    }

    /**
     * <p>
     * Crea un nuevo usuario en el sistema.
     * </p>
     * 
     * @param user
     *            Usuario que va a ser creado.
     * @return Devuelve el resultado de la creación del usuario. Revisar la
     *         clase devuelta para conocer el tipo de resultados y errores que
     *         pueden darse.
     */
    public UserUpdateResult createUser(User user) {

	trimObjectStringData(user);

	UserUpdateResult res = new UserUpdateResult();
	UserDAO dao = new UserDAO();

	// validamos los datos de usuario
	res.addErrors(validateUserData(user, true));

	// si no ha habido errores de validación de datos comprobamos
	// si ya existe el nombre de usuario y si la password cumple las
	// condiciones necesarias
	if (res.getErrors().isEmpty()) {

	    User userRetrieved = dao.retrieveUserByUserName(user.getUserName());
	    if (userRetrieved != null) {
		res.addError(UserUpdateResultError.USERNAME_EXISTS);
	    }

	    res.addErrors(validatePasswordCombinationUpdate(user));
	}

	if (res.getErrors().isEmpty()) {
	    user.setPassword(user.getNewPassword());
	    dao.createUser(user);
	    res.setUser(dao.retrieveUserByUserName(user.getUserName()));
	}
	return res;
    }

    /**
     * <p>
     * Modifica los datos de un usuario en el sistema.
     * </p>
     * <p>
     * <b>ATENCION!</b>Este método no modifica la clave del usuario.
     * </p>
     * 
     * @param user
     *            Usuario que va a ser modificado basándose en el id del usuario
     *            pasado como parámetro.
     * @return Devuelve el resultado de la modificación del usuario. Revisar la
     *         clase devuelta para conocer el tipo de resultados y errores que
     *         pueden darse.
     */
    public UserUpdateResult updateUser(User user) {
	UserUpdateResult res = new UserUpdateResult();

	trimObjectStringData(user);

	// validamos los datos de usuario
	res.addErrors(validateUserData(user, false));

	if (res.getErrors().isEmpty()) {
	    UserDAO dao = new UserDAO();
	    User userRetrieved = dao.retrieveUserByUserId(user.getId());
	    if (userRetrieved == null) {
		res.addError(UserUpdateResultError.USER_NOT_EXISTS);
	    } else {
		userRetrieved.setFirstName(user.getFirstName());
		userRetrieved.setLastName(user.getLastName());
		userRetrieved.setEmail(user.getEmail());
		userRetrieved.setVehicleType(user.getVehicleType());
		userRetrieved.setFetchGpsLapseTime(user.getFetchGpsLapseTime());

		dao.updateUser(userRetrieved);
		res.setUser(userRetrieved);
	    }
	}
	return res;
    }

    /**
     * <p>
     * Modifica la clave de un usuario en el sistema.
     * </p>
     * 
     * @param user
     *            Usuario cuya clave va a ser modificada basándose en el id del
     *            usuario pasado como parámetro.
     * @return Devuelve el resultado de la modificación de la clave del usuario.
     *         Revisar la clase devuelta para conocer el tipo de resultados y
     *         errores que pueden darse.
     */
    public UserUpdateResult updateUserPassword(User user) {

	UserUpdateResult res = new UserUpdateResult();
	User userRetrieved = null;

	trimObjectStringData(user);

	if (user.getPassword() == null || user.getPassword().isEmpty()) {
	    res.addError(UserUpdateResultError.OLDPASSWORD_BLANK);
	} else {
	    userRetrieved = retrieveUserByUserId(user.getId());
	    if (userRetrieved == null) {
		res.addError(UserUpdateResultError.USER_NOT_EXISTS);
	    } else if (!userRetrieved.getPassword().equals(user.getPassword())) {
		res.addError(UserUpdateResultError.OLDPASSWORD_INCORRECT);
	    } else {
		userRetrieved.setPassword(user.getPassword());
		userRetrieved.setNewPassword(user.getNewPassword());
		userRetrieved.setNewPasswordRepeated(user.getNewPasswordRepeated());
		res.addErrors(validatePasswordCombinationUpdate(userRetrieved));
	    }
	}

	if (res.isUpdateSuccessful()) {
	    userRetrieved.setPassword(userRetrieved.getNewPassword());
	    UserDAO dao = new UserDAO();
	    dao.updateUser(userRetrieved);
	    res.setUser(userRetrieved);
	}

	return res;
    }

    /**
     * <p>
     * Conecta un usuario al servidor.
     * </p>
     * 
     * @param user
     *            Usuario que va a ser conectado.
     * @return Devuelve la sesión creada para el usuario o null si no ha sido
     *         posible conectar al usuario.
     */
    public UserSession connectUser(User user) {
	Collection<UserSessionError> sessionErrors = null;
	UserSession us = userSessions.get(user.getId());
	if (us != null) {
	    sessionErrors = consumeUserSession(us);
	}

	// si ha habido errores al consumir la session es porque el usuario no
	// estaba conectado o la session estaba expirada. Por tanto creamos una
	// nueva session y conectamos al usuario.
	if (sessionErrors == null || !sessionErrors.isEmpty()) {
	    us = new UserSession();
	    us.setUserId(user.getId());
	    us.setSessionToken(generateSessionToken());
	    us.setLastTimeUsed(Calendar.getInstance());
	    userSessions.put(us.getUserId(), us);
	}

	return us;
    }

    /**
     * <p>
     * Desconecta un usuario del servidor basándose en su sesión.
     * </p>
     * 
     * @param userSession
     *            Sesión de usuario que va a ser desconectada.
     */
    public void disconnectUser(UserSession userSession) {
	UserSession us = userSessions.get(userSession.getUserId());
	if (us != null && userSession.getSessionToken().equals(us.getSessionToken())) {
	    userSessions.remove(us.getUserId());
	}
    }

    /**
     * <p>
     * Consume una sesión de usuario.
     * </p>
     * <p>
     * Este método se utiliza para validar que usuario está correctamente
     * conectado y puede ejecutar una acción.
     * </p>
     * 
     * @param session
     *            Sesión que va a ser validada y consumida.
     * @return Colección de posibles errores al consumir la sesión. Ver la clase
     *         UserSessionError para conocer el tipo de errores que puede
     *         lanzarse.
     */
    public Collection<UserSessionError> consumeUserSession(UserSession session) {
	Collection<UserSessionError> res = new ArrayList<UserSessionError>();

	UserSession sessionRead = userSessions.get(session.getUserId());
	if (sessionRead == null) {
	    res.add(UserSessionError.SESSION_NOT_EXISTS);
	} else {
	    if (calculateUserSessionHasExpired(sessionRead)) {
		res.add(UserSessionError.SESSION_EXPIRED);
		disconnectUser(sessionRead);
	    } else {
		sessionRead.setLastTimeUsed(Calendar.getInstance());
	    }
	}

	if (session != null && session.getSessionToken() != null && !res.isEmpty()) {
	    UserController uc = new UserController();
	    UserActivityLog activity = new UserActivityLog();
	    activity.setUserId(session.getUserId());
	    activity.setSessionToken(session.getSessionToken());
	    activity.setAction(UserAction.INTENT_WITH_SESSION_EXPIRED);
	    String activityId = uc.startUserActivityLog(activity);
	    uc.terminateUserActivityLog(activityId);
	}

	return res;
    }

    /**
     * <p>
     * Graba un registro de actividad de un usuario, sólo como iniciada.
     * </p>
     * <p>
     * La finalización correcta de una acción se realiza con el método
     * terminateUserActivity.
     * </p>
     * 
     * @param action
     *            Acción a grabar.
     * @return Identificador de la acción registrada para poder registrar su
     *         finalización.
     */
    public String startUserActivityLog(UserActivityLog activity) {
	String id = UUID.randomUUID().toString();
	activity.setActivityId(id);
	activity.setTimestamp(Calendar.getInstance());
	UserActivityLogDAO dao = new UserActivityLogDAO();
	dao.createUserActivity(activity);
	return id;
    }

    /**
     * <p>
     * Modifica un registro de actividad de un usuario, para indicar que ha
     * finalizado correctamente.
     * </p>
     * 
     * @param activityId
     *            Identificador del registro a finalizar.
     */
    public void terminateUserActivityLog(String activityId) {
	UserActivityLogDAO dao = new UserActivityLogDAO();
	dao.terminateUserActivity(activityId);
    }

    /*----------------------------------------------------------------------------------------*/

    /**
     * <p>
     * Genera una cadena aleatoria como token de sesión de usuario.
     * </p>
     * 
     * @return Token generado.
     */
    private String generateSessionToken() {
	return UUID.randomUUID().toString();
    }

    /**
     * <p>
     * Comprueba si la sesión pasada como parámetro ha expirado.
     * </p>
     * 
     * @param userSession
     * @return true si la sesión ha expirado, false si la sesión sigue vigente
     */
    private boolean calculateUserSessionHasExpired(UserSession userSession) {
	boolean res = true;
	if (userSession != null) {
	    long now = Calendar.getInstance().getTimeInMillis();
	    res = userSession.getLastTimeUsed().getTimeInMillis() + SESSION_EXPIRING_TIME < now;
	}
	return res;
    }

    /**
     * <p>
     * Valida la corrección de los datos de usuario.
     * </p>
     * <p>
     * No valida si el nombre de usuario existe ni la corrección de la password,
     * pues no son validaciones aplicables a los distintos casos en que este
     * método se va a utilizar. La creación de usuario, modificación de usuario
     * y modificación de password deberán hacer sus propias comprobaciones al
     * respecto.
     * </p>
     * 
     * @param user
     *            Usuario para ser validado.
     * @param userCreation
     *            Indica si las validaciones se deben realizar para una creación
     *            de usuario o bien para una modificación de usuario.
     * @return Colección de posibles errores producidos al validar el usuario.
     *         Ver la clase UserUpdateResultError para conocer el tipo de
     *         errores que puede lanzarse.
     */
    private Collection<UserUpdateResultError> validateUserData(User user, boolean userCreation) {
	Collection<UserUpdateResultError> res = new ArrayList<UserUpdateResultError>();

	if (userCreation) {
	    if (user.getUserName() == null || user.getUserName().isEmpty()) {
		// nombre de usuario vacio
		res.add(UserUpdateResultError.USERNAME_BLANK);
	    } else if (user.getUserName().length() < MINUSERNAMELENGTH) {
		// nombre de usuario demasiado corto
		res.add(UserUpdateResultError.USERNAME_TOOSHORT);
	    }
	}

	if (user.getFirstName().isEmpty()) {
	    // firstName vacio
	    res.add(UserUpdateResultError.FIRSTNAME_BLANK);
	}

	if (user.getLastName().isEmpty()) {
	    // lastName vacio
	    res.add(UserUpdateResultError.LASTNAME_BLANK);
	}

	if (user.getEmail().isEmpty()) {
	    // email vacio
	    res.add(UserUpdateResultError.EMAIL_BLANK);
	} else if (!validateEmail(user.getEmail())) {
	    // email incorrecto
	    res.add(UserUpdateResultError.EMAIL_INCORRECT);
	} else if (userCreation && !user.getEmail().equals(user.getEmailRepeated())) {
	    // email incorrecto
	    res.add(UserUpdateResultError.EMAILS_UNMATCHED);
	}

	if (user.getFetchGpsLapseTime() == null) {
	    // gpsFetchTime vacio
	    res.add(UserUpdateResultError.GPSFETCHTIME_BLANK);
	} else if (user.getFetchGpsLapseTime() == Long.MIN_VALUE) {
	    // gpsFetchTime incorrecto
	    res.add(UserUpdateResultError.GPSFETCHTIME_INCORRECT);
	} else if (user.getFetchGpsLapseTime() < MINGPSFETCHTIME) {
	    // gpsFetchTime demasiado pequeño
	    res.add(UserUpdateResultError.GPSFETCHTIME_TOOSMALL);
	} else if (user.getFetchGpsLapseTime() > MAXGPSFETCHTIME) {
	    // gpsFetchTime demasiado grande
	    res.add(UserUpdateResultError.GPSFETCHTIME_TOOBIG);
	}

	if (user.getVehicleType() == null) {
	    // tipo de vehículo vacio
	    res.add(UserUpdateResultError.VEHICLETYPE_BLANK);
	}

	return res;
    }

    /**
     * <p>
     * Valida que el email pasado como parámetro sea correcto.
     * </p>
     * 
     * @param email
     *            Email para ser validado.
     * @return true si el email es correcto, false en caso contrario.
     */
    private boolean validateEmail(String email) {
	boolean res = false;
	if (!email.isEmpty()) {
	    String regex = "^[_A-Za-z0-9-\\+]+(\\.[_A-Za-z0-9-]+)*@[A-Za-z0-9-]+(\\.[A-Za-z0-9]+)*(\\.[A-Za-z]{2,})$";
	    res = email.matches(regex);
	}
	return res;
    }

    /**
     * <p>
     * Valida la combinación de claves del usuario para la creación del mismo o
     * el cambio de clave.
     * </p>
     * <p>
     * Si la password del primer parametro, user, tiene valor se realizan
     * también validaciones contra ella porque se trata de un cambio de clave.
     * </p>
     * 
     * @param user
     *            Usuario que contiene las claves que serán validadas.
     * @return Colección de posibles errores producidos al validar la
     *         combinación de claves. Ver la clase UserUpdateResultError para
     *         conocer el tipo de errores que puede lanzarse.
     */
    private Collection<UserUpdateResultError> validatePasswordCombinationUpdate(User user) {
	Collection<UserUpdateResultError> errors = new ArrayList<UserUpdateResultError>();

	if (user.getNewPassword() == null || user.getNewPassword().isEmpty()) {
	    errors.add(UserUpdateResultError.NEWPASSWORD_BLANK);
	} else if (user.getNewPasswordRepeated() == null || user.getNewPasswordRepeated().isEmpty()) {
	    errors.add(UserUpdateResultError.NEWPASSWORDS_UNMATCHED);
	} else if (!user.getNewPassword().equals(user.getNewPasswordRepeated())) {
	    errors.add(UserUpdateResultError.NEWPASSWORDS_UNMATCHED);
	}

	if (errors.isEmpty()) {
	    if (user.getPassword() != null && user.getNewPassword().equals(user.getPassword())) {
		errors.add(UserUpdateResultError.NEWPASSWORD_REPEATED);
	    }

	    errors.addAll(validatePasswordAppropriateness(user));
	}

	return errors;
    }

    /**
     * <p>
     * Valida que la clave sea apropiada según los siguientes criterios:
     * </p>
     * <ul>
     * <li>Debe tener una longitud mínima de MINPASSWORDLENGTH caracteres</li>
     * <li>No debe incluir el userName, firstName o lastName del usuario
     * independientemente de su capitalización</li>
     * <li>Debe contener al menos un caracter de al menos tres de los grupos
     * siguientes: mayúsculas, minúsculas, números, caracteres especiales. <br>
     * <i>Los caracteres especiales incluyen los siguientes: coma(,), punto(.),
     * guión(-), subrayado(_), tilde(~), almohadilla(#)</i></li>
     * </ul>
     * 
     * @param user
     *            Usuario que contiene la clave que será validada.
     * @return Colección de posibles errores producidos al validar la clave. Ver
     *         la clase UserUpdateResultError para conocer el tipo de errores
     *         que puede lanzarse.
     */
    private Collection<UserUpdateResultError> validatePasswordAppropriateness(User user) {
	Collection<UserUpdateResultError> errors = new ArrayList<UserUpdateResultError>();
	String password = user.getNewPassword();
	// validamos la longitud de la clave
	if (password.length() < MINPASSWORDLENGTH) {
	    errors.add(UserUpdateResultError.NEWPASSWORD_TOOSHORT);
	}

	// validamos posibles datos personales incluidos en la clave
	String npUpper = new String(password).toUpperCase();
	if (npUpper.indexOf(user.getUserName().toUpperCase()) >= 0 || npUpper.indexOf(user.getFirstName().toUpperCase()) >= 0
		|| npUpper.indexOf(user.getLastName().toUpperCase()) >= 0) {
	    errors.add(UserUpdateResultError.NEWPASSWORD_PERSONALDATAINCLUDED);
	}

	// validamos que no contenga caracteres inválidos
	String regexGeneral = "[A-Za-z0-9._\\-~#]+";
	if (!password.matches(regexGeneral)) {
	    errors.add(UserUpdateResultError.NEWPASSWORD_CARACTERSNOTALLOWED);
	} else {
	    // validamos los grupos de caracteres contenidos
	    String regexUpper = ".*[A-Z]+.*";
	    String regexLower = ".*[a-z]+.*";
	    String regexNumber = ".*[0-9]+.*";
	    String regexSpecial = ".*[._\\-~#]+.*";
	    int charConditionsMatched = 0;

	    if (password.matches(regexUpper)) {
		charConditionsMatched++;
	    }
	    if (password.matches(regexLower)) {
		charConditionsMatched++;
	    }
	    if (password.matches(regexNumber)) {
		charConditionsMatched++;
	    }
	    if (password.matches(regexSpecial)) {
		charConditionsMatched++;
	    }

	    if (charConditionsMatched < 3) {
		errors.add(UserUpdateResultError.NEWPASSWORD_CARACTERSNOTMIXED);
	    }
	}

	return errors;
    }

    /**
     * <p>
     * Método auxiliar para rellenar en el usuario datos propios de la sesión
     * como si está conectado.
     * </p>
     * 
     * @param user
     *            Usuario que será completado.
     */
    private void completeUserSessionData(User user) {
	if (user != null) {
	    UserSession us = userSessions.get(user.getId());
	    if (us != null) {
		if (!calculateUserSessionHasExpired(us)) {
		    user.setConnected(true);
		}
	    }
	}
    }

    /**
     * <p>
     * Método auxiliar para realizar un limpiado de espacios en blanco (trim) de
     * los datos del usuario antes de su validación.
     * </p>
     * <p>
     * Utiliza introspección para que pueda ser aplicado a distintos objetos.
     * </p>
     * 
     * @param obj
     *            Objeto a ser limpiado.
     */
    private void trimObjectStringData(Object obj) {

	@SuppressWarnings("rawtypes")
	Class[] setterParams = new Class[1];
	setterParams[0] = String.class;

	@SuppressWarnings("rawtypes")
	Class mClass = obj.getClass();
	Method[] mths = mClass.getMethods();
	for (Method me : mths) {
	    String meName = me.getName();
	    if (me.getReturnType().equals(String.class) && meName.startsWith("get")) {
		try {
		    String value = (String) me.invoke(obj);
		    if (value != null) {
			value = value.trim();
			String setterName = "set" + meName.substring(3);

			@SuppressWarnings("unchecked")
			Method setter = mClass.getMethod(setterName, setterParams);
			setter.invoke(obj, value);

		    }
		} catch (NoSuchMethodException e) {
		} catch (InvocationTargetException e) {
		} catch (IllegalAccessException e) {
		}
	    }
	}
    }

}
